fix(rest): type the two production exceljs dynamic imports behind one named accessor - #13452
Conversation
… named accessor Both places `packages/rest` production source reached exceljs bound the module as `const ExcelJS: any`, so `parseXlsxToRows` (the whole .xlsx import path) and `createXlsxStream` (the streaming .xlsx export path) built workbooks, read worksheets, iterated rows and read cells through a value tsc knew nothing about. A misspelled method, a wrong arity or a property exceljs renamed was not a compile error, only a runtime fault in a deployed import or export. `src/xlsx-module.ts` is now the single binding site both paths share. It keeps the load lazy (everything is either a type, erased at emit, or inside the async accessor), and it states the trade it accepts: typing the dynamic import pulls exceljs's declarations -- including the module-local `Buffer` shim -- into production modules that previously kept them out. The cost drops from "the whole path is unchecked" to "one named assertion with its reason written next to it". That assertion, `asXlsxLoadInput`, takes `Buffer` and not `Buffer | ArrayBuffer` deliberately: `ArrayBuffer` is already assignable to exceljs's shim, so that arm reaches `load` unasserted and stays checked. `wb.getWorksheet(sheet as any)` loses its cast for the same reason -- the real signature accepts `string | number` as it stands. Typing-only. No runtime behaviour change: the interop expression is the one the call sites already ran (awaited once rather than twice -- the second await resolved from the module cache to the identical record), and `Row.values`'s non-array shape iterates zero times before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…typing Measured rather than assumed: the accessor reaches the published artifact (`loadExcelJs` greps 3 in dist/index.js and 3 in dist/index.cjs, positive control `RestServer` = 36), so this PR releases something and `skip-changeset` would be false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…oduction-exceljs-typing
… re-anchor the isSystem census The typed accessor's TYPE half is asserted by tsc; its runtime half is not, and two things there can rot silently. `xlsx-module.test.ts` pins both: the CommonJS interop (drop either half of `.default ?? namespace` and `Workbook` is undefined, visible only in a deployed import/export), and both arms of `parseXlsxToRows(buffer: Buffer | ArrayBuffer, ...)` -- the Node `Buffer` arm that carries the assertion and the `ArrayBuffer` arm that stays checked without one -- driven against bytes the accessor itself wrote. `check:system-context-census` went red on this branch and the cause is this branch: the one import line added to `rest-server.ts` shifted every elevation read below it by one, so nine anchors on `content/docs/permissions/system-context.mdx` pointed one line short. Measured, not assumed -- with `rest-server.ts` alone restored to the merge base the gate reads `OK - 109 elevation read sites ... 145 anchors resolve`. `--fix` refuses this one by design (it counts 8 page read-anchors against 6 census sites and calls that a population change, not a shift), so the nine anchors are bumped by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0412f951811d5189dadbe80a22daa1085cd22dd0 && git checkout 0412f951811d5189dadbe80a22daa1085cd22dd0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 71627f7b4e9f7919784dc21006fdb6b2720104b8 9d33b427c61e2287a2cee7db2643cc660ff5afe1 && git checkout -B drift-repro 71627f7b4e9f7919784dc21006fdb6b2720104b8 && git merge --no-ff 9d33b427c61e2287a2cee7db2643cc660ff5afe1
node scripts/docs-audit/affected-docs.mjs --json 71627f7b4e9f7919784dc21006fdb6b2720104b8 |
…ng, and stops claiming the swallow is documented nowhere
Comment-only. Item 3 of the 2026-08-29 ride-along ruling: the census file is
where a reader of this door lands, and it named two `.catch(() => undefined)`
sites without saying that a production fault reaches NEITHER of them as a
rejection. `computeExecCtx` wraps its whole body in `try { ... } catch { return
undefined; }`, so the resolve FULFILS with `undefined` and the
fault-to-anonymous conversion has already happened one level below -- which is
exactly the mistake the ruling names, "stops a future reader from removing the
wrong `.catch` expecting a fault to surface". It also records that this first
net is NOT per-door: the two `.catch`es are per-consumer (16 of them in
rest-server.ts) while computeExecCtx's catch is one site every consumer
inherits.
Both facts are cited, not restated -- they are measured in
package-door-execctx-fault-reading.test.ts,
package-door-execctx-fault-reachability.test.ts and
execctx-consumer-census.test.ts, and a second copy is a second thing to drift.
The same paragraph's closing note said the swallow is "documented at NEITHER
site". That is now half stale and was corrected in place rather than left
adjacent to a fresh correction: rest-server.ts's
resolvePackageRouteExecutionContext carries the reading (the second-net point
included); the package-routes.ts site still carries none.
Comment-only proven with scripts/js-comment-mask.mjs, on a comparator
calibrated in BOTH directions: code identity 9b977535ecc0592f before and after,
while a one-token code change to the same file DIFFERS and a comment word
change does not. Raw bytes differ (5dbceb5dddb84287 -> d74dac3166529e9f), so
the green is not "nothing happened". rest-server.ts is untouched, so the nine
hand-bumped anchors in content/docs/permissions/system-context.mdx cannot have
moved; check:system-context-census re-reads OK over 109 sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…oduction-exceljs-typing
Fixes #13426
Fixes #12537
Both places
packages/restproduction source reached exceljs bound the module asconst ExcelJS: any, soparseXlsxToRows(the whole .xlsx import path) andcreateXlsxStream(the streaming .xlsx export path) built workbooks, readworksheets, iterated rows and read cells through a value tsc knew nothing about. A
misspelled method, a wrong argument arity or a property exceljs renamed was not a
compile error — it surfaced, if at all, as a runtime fault in a deployed import or
export.
The accessor — one named binding site, shared by both production paths
packages/rest/src/xlsx-module.tsis now the only production file in this packagethat names exceljs. It exports
loadExcelJs()(the lazy, typed namespace),asXlsxLoadInput()(the one assertion), and re-exports the exceljs types the twocall sites need. The parameter type is read off the dependency's own signature —
the
Parameters[0]of exceljs'sXlsxloadmember — rather than spelled byhand, the technique the test-layer loader on open PR #13425 (card #13378)
introduced. If exceljs ever drops its shim, that alias resolves to Node's
Bufferand the assertion quietly becomes a no-op rather than a lie.
The lazy import is preserved and is still the point. Everything in the module
is either a type (erased at emit) or inside the async accessor, so a CSV or JSON
import still never pays to load exceljs. Nothing here is a static import.
The
Bufferarm, and only theBufferarmasXlsxLoadInputtakesBuffer, notBuffer | ArrayBuffer.ArrayBufferisalready assignable to exceljs's module-local shim, so that arm reaches
loadunasserted and stays genuinely checked:
A blanket assertion over the union would have switched off type checking that works
today. Both arms are pinned at runtime by the new test file. In the same spirit,
wb.getWorksheet(sheet as any)loses its cast — the real signature accepts a stringor a number as it stands.
The trade, written into the accessor's comment
Typing the dynamic import does pull exceljs's declarations — including the
module-local
Buffershim — into production modules that previously kept them out.That is accepted deliberately, and it is a trade down: before this branch the
cost was the whole path is unchecked; after it the cost is one named assertion
with its reason written next to it, in a place a future exceljs release can retire
in one edit. The accessor's docblock states it.
Proof that the typing bites
A green typecheck alone proves nothing here, so the same deliberate mutation was
driven through both the typed code and the base code — a misspelled
wb.xlsx.loadon the import path and a misspelledws.addRowat the exportcaller, injection confirmed on disk by grep counts and blob hashes, restored and
verified by blob hash plus an empty
git diff HEAD.anycodeThe base leg is the load-bearing half: zero diagnostics for the identical mutation
is the measurement that the path really was outside the type system, and is now
inside it.
Anti-vacuity.
tsc --noEmit --listFilesinpackages/restlists 452 files andcontains all three edited production files; exceljs's
index.d.tsis in the program(positive control that the declarations are live); a
.test.tscount of 0 is thenegative control for that program's own
exclude. The ablation needed no rebuildand is not exempt from the rule: the mutated files are read from
src/by the sameprogram that renders the verdict, which is exactly what
--listFilesshows.The census repair this branch owed
check:system-context-censuswent red here, and the cause was this branch: the oneimport line added to
rest-server.tsshifted every elevation read below it by one,so nine anchors on
content/docs/permissions/system-context.mdxpointed one lineshort. Measured rather than assumed — with
rest-server.tsalone restored to themerge base the gate reads
OK — 109 elevation read sites … 145 anchors resolve.--fixrefuses this shape by design (it counts 8 page read-anchors against 6 censussites and calls that a population change, not a shift), so the nine anchors are
bumped by hand. Gate now green.
Changeset — measured, not copied
@objectstack/restgets apatch. The accessor does reach the publishedartifact:
loadExcelJsgreps 3 indist/index.jsand 3 indist/index.cjs(
asXlsxLoadInput2 and 2), against a positive control ofRestServer= 36 and anegative control of
loadXlsxWorkbook= 0. That is the opposite reading from thetest-layer helper on PR #13425, whose
skip-changesetwas earned by a grep of 0 —so it is not borrowed here.
It is nevertheless typing-only:
parseXlsxToRowskeeps its exact signature, thepackage's exports are unchanged,
loadExcelJsgreps 0 indist/index.d.ts, and thesuite is green unchanged (2698 tests before this branch's own new file, 2703 with
it). The interop expression is the one the call sites already ran, awaited once
rather than twice — the second
await import('exceljs')resolved from the modulecache to the identical record.
Discharging #12537 — items 1 and 2 already on
main, item 3 added hereThe 2026-08-29 ruling (verbatim 「同意」) is a three-part ride-along, to be carried
by the next
rest-server.tsvehicle and explicitly never its own PR. This PR is thatvehicle. My first pass reported the card as entirely stale; that was two-thirds right,
and the third part really was missing.
resolveExecutionContextsection as a test injection pointmainpackage-door-declared-code.test.ts:284main:71and:260computeExecCtxcorrection into the census fileItem 3's absence was a reading, not an instrument failure — the zeros were taken with
positive controls in the same file:
computeExecCtx0 andsecond net|absorbed|one level|outer catch0, againstresolveExecCtx2,TEST-ONLY2 and
Seam census7.What was added, and why it is that file's problem. The census file names two
.catch(() => undefined)sites and told a reader that a production resolver's"rejections are swallowed twice". A production fault reaches neither of them as a
rejection:
computeExecCtxwraps its whole body intry { … } catch { return undefined; }, so the resolve fulfils withundefinedand the fault-to-anonymous conversion has already happened one level below. Deleting
either
.catchexpecting a fault to surface therefore changes nothing — the mistakethe ruling exists to prevent. The new block also records that this first net is not
per-door: the two
.catches are per-consumer (16 inrest-server.ts) whilecomputeExecCtx's catch is one site every consumer inherits. Both facts are cited,not restated —
package-door-execctx-fault-reading.test.ts,package-door-execctx-fault-reachability.test.tsandexecctx-consumer-census.test.tsown the measurements, and a second copy is a second thing to drift. The citation ran
siblings to census file only; now it runs both ways.
One adjacent sentence corrected in the same paragraph. It read "the swallow is
documented at NEITHER site". That is now half stale, and leaving a false claim beside a
fresh correction would be worse than the correction is worth:
rest-server.ts'sresolvePackageRouteExecutionContextcarries the reading (thesecond-net point included); the
package-routes.tssite still carries none. Thesentence now says exactly that.
⛔ Option A (propagating the resolver fault as a 5xx) is not done here — declined by
the maintainer — and the swallow behaviour question was split out to #13255, so the
record correction is all that remained on this card.
Comment-only, on a two-way-calibrated instrument. The change to
package-door-declared-code.test.tsis 37 added and 2 removed comment lines and nocode. Proven with
scripts/js-comment-mask.mjs(stripComments), comparing codeidentity after excluding whitespace-only differences:
stripCommentsoutputraw says NOT comment-only here, and it is wrong for a reason worth writing down —
that function keeps every newline by design so line numbers survive, so N added comment
lines add N newlines to its output. The measured delta was +35 newlines, +0 spaces, +0
non-whitespace characters, with all 338 non-empty code lines identical in content and
order. The comparator that answers "did any code change" has to exclude whitespace; the
raw one answers "did the line map move".
rest-server.tsis untouched by this commit, so the nine hand-bumped anchors incontent/docs/permissions/system-context.mdxcannot have moved — andcheck:system-context-censusre-readsOK — 109 elevation read sites in 20 packages across 45 files, all anchoredat thefinal head.
Two corrections to my own earlier report
:259and:281. They are:260and:284. The cause is not a stale read channel: both were260/284on the exacttree I read (verified at
74d57faf7fand atbac201e928). I quoted them off a rangedump instead of a grep. The git readings in this PR were all taken by grep.
resolveExecutionContextseam is TEST-ONLY —resolveExecCtxisprivate asyncbehind.catch(() => undefined), so no production throw leaves it #12537 was fully discharged. It was not. The two items Iverified were real; I generalised from them instead of enumerating the ruling.
Verification
All at
9d33b427c6, this branch's head, with the currentorigin/main(71627f7b4e)merged in before the gate union was derived.
pnpm --filter @objectstack/rest typecheck— exit 0;check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 3 file(s) / 4 error(s) held in test-typecheck-debt.json(that ledger reads 4 where this PR's earlier revision read 5 — PR fix(rest): state the exceljs Buffer-shim assertion once, in a typed test loader #13425 shrank it on
mainand the merge brought that in; shrink-only, and not this branch's doing)pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2—Test Files 161 passed (161),Tests 2703 passed (2703)pnpm lint(eslint . --no-inline-config, whole repo) — exit 0pnpm check:nul-bytes—OK (scanned 7450 text file(s) … no raw ASCII control bytes),plus a control-byte grep over this PR's seven files with zero hits
44 run green at this head, 4 recorded NOT MEASURED below. Among the green:
check:system-context-census(
OK — 109 elevation read sites in 20 packages across 45 files, all anchored),check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:dispatcher-error-vocabulary,check:comment-mask-adoption,check:changeset-gate-self-tests,check:route-envelope,check:authz-resolver, and the docs families the census editpulled in.
Re-deriving on the tree as handed to me printed no staleness banner and exited 0 while
origin/mainwas two commits ahead — one of them #13447, which rewritesdispatch-gates.mjs's own watch-hint machinery (220 lines), i.e. exactly the code thatdecides which families a path derives. The banner's silence is not evidence of
freshness;
git rev-list --count HEAD..origin/mainis. After merging, the re-derivedlist came out identical (44 + 4), so nothing was missed this time — but that could
only be known by re-deriving, not by trusting the exit code.
NOT MEASURED — four, none of them a red, each in the gate's own words:
check:dual-build-cjs-loads—PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.(36 packages: 8 named, "… 28 more"). Repo-scale build; CI owns it.check:type-check-debt— refuses outright:--re-measure cannot run: 26 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk, and its own text says a number taken from here would measure a different world. Its two nearby halves were measured green:check:type-check-coverage(the structural half) and this package's own test-layer ratchet insidetypecheck.scripts/check-test-completeness.mjs— exit 3,PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. Its own help text names this exact invocation and says to record NOT MEASURED.scripts/pm/check-half-states.mjs— exit 3,PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential. (pnpm check:pm-half-states, a different invocation, ran green.)draft: false. I readthat rather than assumed it, and preserved it on this edit instead of reverting it —
flipping someone else's ready state is not mine to do. Flagging it only so the record
says who did what.
Generated by Claude Code